a tool for shared writing and social publishing
1"use client";
2import { publishToPublication } from "actions/publishToPublication";
3import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4import { ActionButton } from "components/ActionBar/ActionButton";
5import {
6 PubIcon,
7 PubListEmptyContent,
8 PubListEmptyIllo,
9} from "components/ActionBar/Publications";
10import { ButtonPrimary, ButtonTertiary } from "components/Buttons";
11import { AddSmall } from "components/Icons/AddSmall";
12import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
13import { PublishSmall } from "components/Icons/PublishSmall";
14import { useIdentityData } from "components/IdentityProvider";
15import { InputWithLabel } from "components/Input";
16import { Menu, MenuItem } from "components/Menu";
17import {
18 useLeafletDomains,
19 useLeafletPublicationData,
20} from "components/PageSWRDataProvider";
21import { Popover } from "components/Popover";
22import { SpeedyLink } from "components/SpeedyLink";
23import { useToaster } from "components/Toast";
24import { DotLoader } from "components/utils/DotLoader";
25import { normalizePublicationRecord } from "src/utils/normalizeRecords";
26import { useParams, useRouter, useSearchParams } from "next/navigation";
27import { useState, useMemo, useEffect } from "react";
28import { useIsMobile } from "src/hooks/isMobile";
29import { useReplicache, useEntity } from "src/replicache";
30import { useSubscribe } from "src/replicache/useSubscribe";
31import { Json } from "supabase/database.types";
32import {
33 useBlocks,
34 useCanvasBlocksWithType,
35} from "src/hooks/queries/useBlocks";
36import * as Y from "yjs";
37import * as base64 from "base64-js";
38import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
39import { BlueskyLogin } from "app/login/LoginForm";
40import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
41import { AddTiny } from "components/Icons/AddTiny";
42import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
43import { useLocalPublishedAt } from "components/Pages/Backdater";
44
45export const PublishButton = (props: { entityID: string }) => {
46 let { data: pub } = useLeafletPublicationData();
47 let params = useParams();
48 let router = useRouter();
49
50 if (!pub) return <PublishToPublicationButton entityID={props.entityID} />;
51 if (!pub?.doc)
52 return (
53 <ActionButton
54 primary
55 icon={<PublishSmall className="shrink-0" />}
56 label={"Publish!"}
57 onClick={() => {
58 router.push(`/${params.leaflet_id}/publish`);
59 }}
60 />
61 );
62
63 return <UpdateButton />;
64};
65
66const UpdateButton = () => {
67 let [isLoading, setIsLoading] = useState(false);
68 let { data: pub, mutate } = useLeafletPublicationData();
69 let { permission_token, rootEntity, rep } = useReplicache();
70 let { identity } = useIdentityData();
71 let toaster = useToaster();
72
73 // Get title and description from Replicache state (same as draft editor)
74 // This ensures we use the latest edited values, not stale cached data
75 let replicacheTitle = useSubscribe(rep, (tx) =>
76 tx.get<string>("publication_title"),
77 );
78 let replicacheDescription = useSubscribe(rep, (tx) =>
79 tx.get<string>("publication_description"),
80 );
81
82 // Use Replicache state if available, otherwise fall back to pub data
83 const currentTitle =
84 typeof replicacheTitle === "string" ? replicacheTitle : pub?.title || "";
85 const currentDescription =
86 typeof replicacheDescription === "string"
87 ? replicacheDescription
88 : pub?.description || "";
89
90 // Get tags from Replicache state (same as draft editor)
91 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
92 const currentTags = Array.isArray(tags) ? tags : [];
93
94 // Get cover image from Replicache state
95 let coverImage = useSubscribe(rep, (tx) =>
96 tx.get<string | null>("publication_cover_image"),
97 );
98
99 // Get post preferences from Replicache state
100 let postPreferences = useSubscribe(rep, (tx) =>
101 tx.get<{
102 showComments?: boolean;
103 showMentions?: boolean;
104 showRecommends?: boolean;
105 } | null>("post_preferences"),
106 );
107
108 // Get local published at from Replicache (session-only state, not persisted to DB)
109 let publishedAt = useLocalPublishedAt((s) =>
110 pub?.doc ? s[pub?.doc] : undefined,
111 );
112
113 return (
114 <ActionButton
115 primary
116 icon={<PublishSmall className="shrink-0" />}
117 label={isLoading ? <DotLoader /> : "Update!"}
118 onClick={async () => {
119 if (!pub) return;
120 setIsLoading(true);
121 let result = await publishToPublication({
122 root_entity: rootEntity,
123 publication_uri: pub.publications?.uri,
124 leaflet_id: permission_token.id,
125 title: currentTitle,
126 description: currentDescription,
127 tags: currentTags,
128 cover_image: coverImage,
129 publishedAt: publishedAt?.toISOString(),
130 postPreferences,
131 });
132 setIsLoading(false);
133 mutate();
134
135 if (!result.success) {
136 toaster({
137 content: isOAuthSessionError(result.error) ? (
138 <OAuthErrorMessage error={result.error} />
139 ) : (
140 "Failed to publish"
141 ),
142 type: "error",
143 });
144 return;
145 }
146
147 // Generate URL based on whether it's in a publication or standalone
148 let docUrl = pub.publications
149 ? `${getPublicationURL(pub.publications)}/${result.rkey}`
150 : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`;
151
152 toaster({
153 content: (
154 <div className="font-bold">
155 {pub.doc ? "Updated! " : "Published! "}
156 <SpeedyLink className="underline" href={docUrl}>
157 See Published Post
158 </SpeedyLink>
159 </div>
160 ),
161 type: "success",
162 });
163 }}
164 />
165 );
166};
167
168const PublishToPublicationButton = (props: { entityID: string }) => {
169 let { identity } = useIdentityData();
170 let { permission_token } = useReplicache();
171 let query = useSearchParams();
172 let [open, setOpen] = useState(query.get("publish") !== null);
173
174 let isMobile = useIsMobile();
175 identity && identity.atp_did && identity.publications.length > 0;
176 let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined);
177 let router = useRouter();
178 let { title, entitiesToDelete } = useTitle(props.entityID);
179 let [description, setDescription] = useState("");
180
181 return (
182 <Popover
183 asChild
184 open={open}
185 onOpenChange={(o) => setOpen(o)}
186 side={isMobile ? "top" : "right"}
187 align={isMobile ? "center" : "start"}
188 className="sm:max-w-sm w-[1000px]"
189 trigger={
190 <ActionButton
191 primary
192 icon={<PublishSmall className="shrink-0" />}
193 label={"Publish on ATP"}
194 />
195 }
196 >
197 {!identity || !identity.atp_did ? (
198 <div className="-mx-2 -my-1">
199 <div
200 className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`}
201 >
202 <div className="mx-auto pt-2 scale-90">
203 <PubListEmptyIllo />
204 </div>
205 <div className="pt-1 font-bold">Publish on AT Proto</div>
206 {
207 <>
208 <div className="pb-2 text-secondary text-xs">
209 Link a Bluesky account to start <br /> a publishing on AT
210 Proto
211 </div>
212
213 <BlueskyLogin
214 compact
215 redirectRoute={`/${permission_token.id}?publish`}
216 />
217 </>
218 }
219 </div>
220 </div>
221 ) : (
222 <div className="flex flex-col">
223 <PostDetailsForm
224 title={title}
225 description={description}
226 setDescription={setDescription}
227 />
228 <hr className="border-border-light my-3" />
229 <div>
230 <PubSelector
231 publications={identity.publications}
232 selectedPub={selectedPub}
233 setSelectedPub={setSelectedPub}
234 />
235 </div>
236 <hr className="border-border-light mt-3 mb-2" />
237
238 <div className="flex gap-2 items-center place-self-end">
239 {selectedPub !== "looseleaf" && selectedPub && (
240 <SaveAsDraftButton
241 selectedPub={selectedPub}
242 leafletId={permission_token.id}
243 metadata={{ title: title, description }}
244 entitiesToDelete={entitiesToDelete}
245 />
246 )}
247 <ButtonPrimary
248 disabled={selectedPub === undefined}
249 onClick={async (e) => {
250 if (!selectedPub) return;
251 e.preventDefault();
252 if (selectedPub === "create") return;
253
254 // For looseleaf, navigate without publication_uri
255 if (selectedPub === "looseleaf") {
256 router.push(
257 `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`,
258 );
259 } else {
260 router.push(
261 `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`,
262 );
263 }
264 }}
265 >
266 Next{selectedPub === "create" && ": Create Pub!"}
267 </ButtonPrimary>
268 </div>
269 </div>
270 )}
271 </Popover>
272 );
273};
274
275const SaveAsDraftButton = (props: {
276 selectedPub: string | undefined;
277 leafletId: string;
278 metadata: { title: string; description: string };
279 entitiesToDelete: string[];
280}) => {
281 let { mutate } = useLeafletPublicationData();
282 let { rep } = useReplicache();
283 let [isLoading, setIsLoading] = useState(false);
284
285 return (
286 <ButtonTertiary
287 onClick={async (e) => {
288 if (!props.selectedPub) return;
289 if (props.selectedPub === "create") return;
290 e.preventDefault();
291 setIsLoading(true);
292 await moveLeafletToPublication(
293 props.leafletId,
294 props.selectedPub,
295 props.metadata,
296 props.entitiesToDelete,
297 );
298 await Promise.all([rep?.pull(), mutate()]);
299 setIsLoading(false);
300 }}
301 >
302 {isLoading ? <DotLoader /> : "Save as Draft"}
303 </ButtonTertiary>
304 );
305};
306
307const PostDetailsForm = (props: {
308 title: string;
309 description: string;
310 setDescription: (d: string) => void;
311}) => {
312 return (
313 <div className=" flex flex-col gap-1">
314 <div className="text-sm text-tertiary">Post Details</div>
315 <div className="flex flex-col gap-2">
316 <InputWithLabel label="Title" value={props.title} disabled />
317 <InputWithLabel
318 label="Description (optional)"
319 textarea
320 value={props.description}
321 className="h-[4lh]"
322 onChange={(e) => props.setDescription(e.currentTarget.value)}
323 />
324 </div>
325 </div>
326 );
327};
328
329const PubSelector = (props: {
330 selectedPub: string | undefined;
331 setSelectedPub: (s: string) => void;
332 publications: {
333 identity_did: string;
334 indexed_at: string;
335 name: string;
336 record: Json | null;
337 uri: string;
338 }[];
339}) => {
340 // HEY STILL TO DO
341 // test out logged out, logged in but no pubs, and pubbed up flows
342
343 return (
344 <div className="flex flex-col gap-1">
345 <div className="text-sm text-tertiary">Publish to…</div>
346 {props.publications.length === 0 || props.publications === undefined ? (
347 <div className="flex flex-col gap-1">
348 <div className="flex gap-2 menuItem">
349 <LooseLeafSmall className="shrink-0" />
350 <div className="flex flex-col leading-snug">
351 <div className="text-secondary font-bold">
352 Publish as Looseleaf
353 </div>
354 <div className="text-tertiary text-sm font-normal">
355 Publish this as a one off doc to AT Proto
356 </div>
357 </div>
358 </div>
359 <div className="flex gap-2 px-2 py-1 ">
360 <PublishSmall className="shrink-0 text-border" />
361 <div className="flex flex-col leading-snug">
362 <div className="text-border font-bold">
363 Publish to Publication
364 </div>
365 <div className="text-border text-sm font-normal">
366 Publish your writing to a blog on AT Proto
367 </div>
368 <hr className="my-2 drashed border-border-light border-dashed" />
369 <div className="text-tertiary text-sm font-normal ">
370 You don't have any Publications yet.{" "}
371 <a target="_blank" href="/lish/createPub">
372 Create one
373 </a>{" "}
374 to get started!
375 </div>
376 </div>
377 </div>
378 </div>
379 ) : (
380 <div className="flex flex-col gap-1">
381 <PubOption
382 selected={props.selectedPub === "looseleaf"}
383 onSelect={() => props.setSelectedPub("looseleaf")}
384 >
385 <LooseLeafSmall />
386 Publish as Looseleaf
387 </PubOption>
388 <hr className="border-border-light border-dashed " />
389 {props.publications.map((p) => {
390 let pubRecord = normalizePublicationRecord(p.record);
391 return (
392 <PubOption
393 key={p.uri}
394 selected={props.selectedPub === p.uri}
395 onSelect={() => props.setSelectedPub(p.uri)}
396 >
397 <>
398 <PubIcon record={pubRecord} uri={p.uri} />
399 {p.name}
400 </>
401 </PubOption>
402 );
403 })}
404 <div className="flex items-center px-2 py-1 text-accent-contrast gap-2">
405 <AddTiny className="m-1 shrink-0" />
406
407 <a target="_blank" href="/lish/createPub">
408 Start a new Publication
409 </a>
410 </div>
411 </div>
412 )}
413 </div>
414 );
415};
416
417const PubOption = (props: {
418 selected: boolean;
419 onSelect: () => void;
420 children: React.ReactNode;
421}) => {
422 return (
423 <button
424 className={`flex gap-2 menuItem font-bold text-secondary ${props.selected && "bg-[var(--accent-light)]! outline! outline-offset-1! outline-accent-contrast!"}`}
425 onClick={() => {
426 props.onSelect();
427 }}
428 >
429 {props.children}
430 </button>
431 );
432};
433
434let useTitle = (entityID: string) => {
435 let rootPage = useEntity(entityID, "root/page")[0].data.value;
436 let canvasBlocks = useCanvasBlocksWithType(rootPage).filter(
437 (b) => b.type === "text" || b.type === "heading",
438 );
439 let blocks = useBlocks(rootPage).filter(
440 (b) => b.type === "text" || b.type === "heading",
441 );
442 let firstBlock = canvasBlocks[0] || blocks[0];
443
444 let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value;
445
446 const leafletTitle = useMemo(() => {
447 if (!firstBlockText) return "Untitled";
448 let doc = new Y.Doc();
449 const update = base64.toByteArray(firstBlockText);
450 Y.applyUpdate(doc, update);
451 let nodes = doc.getXmlElement("prosemirror").toArray();
452 return YJSFragmentToString(nodes[0]) || "Untitled";
453 }, [firstBlockText]);
454
455 // Only handle second block logic for linear documents, not canvas
456 let isCanvas = canvasBlocks.length > 0;
457 let secondBlock = !isCanvas ? blocks[1] : undefined;
458 let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text")
459 ?.data.value;
460 const secondBlockText = useMemo(() => {
461 if (!secondBlockTextValue) return "";
462 let doc = new Y.Doc();
463 const update = base64.toByteArray(secondBlockTextValue);
464 Y.applyUpdate(doc, update);
465 let nodes = doc.getXmlElement("prosemirror").toArray();
466 return YJSFragmentToString(nodes[0]) || "";
467 }, [secondBlockTextValue]);
468
469 let entitiesToDelete = useMemo(() => {
470 let etod: string[] = [];
471 // Only delete first block if it's a heading type
472 if (firstBlock?.type === "heading") {
473 etod.push(firstBlock.value);
474 }
475 // Delete second block if it's empty text (only for linear documents)
476 if (
477 !isCanvas &&
478 secondBlockText.trim() === "" &&
479 secondBlock?.type === "text"
480 ) {
481 etod.push(secondBlock.value);
482 }
483 return etod;
484 }, [firstBlock, secondBlockText, secondBlock, isCanvas]);
485
486 return { title: leafletTitle, entitiesToDelete };
487};